FX Forward Curve¶

Objective: Retrieve and analyze complete FX forward curves (multiple tenors), with smart data caching

Tenors: 1M, 2M, 3M, 6M, 12M

Workflow:

  1. Load configuration and set parameters
  2. Fetch forward curve using ForwardCurve.fetch()
    • 2.1. Align base currency across spot and forward points
    • 2.2. Convert into forward curve with forward scale
    • 2.3. Due to data limitation, tenors in days are hard coded instead of fetching
  3. Analyze term structure and carry opportunities using class methods
  4. Export results

1. Setup & Dependencies¶

import os
import sys
from pathlib import Path
from datetime import datetime, timedelta, date

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

# Configure Plotly for proper HTML export
import plotly.io as pio
pio.renderers.default = "notebook"
# Ensure Plotly plots include JavaScript in the notebook output
import plotly.offline as pyo
pyo.init_notebook_mode(connected=True)

# Add parent directory to path so we can import src package
sys.path.insert(0, str(Path.cwd().parent))

# Import forward curve module from src package - OOP design
from src.forward_curve import (
    load_forward_curve_config,
    ForwardCurve,  # Main OOP class
    # FORWARD_TENORS, # in days 30, 60, 91.... 
    # For appendix (manual step-by-step)
)

# Display settings
pd.set_option('display.max_columns', None)
pd.set_option('display.float_format', '{:.6f}'.format)
sns.set_style('whitegrid')
plt.rcParams['figure.figsize'] = (14, 8)

print("Dependencies loaded")
print("Plotly configured for HTML export")
Dependencies loaded
Plotly configured for HTML export

2. Configuration¶

# Load forward curve ticker configuration
config_file = Path.cwd().parent / 'data' / 'raw' / 'forward_curve_tickers.csv'
config = load_forward_curve_config(config_file)

print(f"Loaded configuration for {len(config)} currencies:")
for ccy in config:
    print(f"  {ccy}: {config[ccy]['spot']}")

# Date range
START_DATE = datetime(2015, 1, 1, 0, 0, 0)
END_DATE = datetime(2025, 11, 15, 0, 0, 0) 

print(f"\nDate range: {START_DATE.date()} to {END_DATE.date()}")
Loaded configuration for 16 currencies:
  USDAED: USDAED Curncy
  AUDUSD: AUDUSD Curncy
  USDBRL: USDBRL Curncy
  USDCAD: USDCAD Curncy
  USDCHF: USDCHF Curncy
  USDCNH: USDCNH Curncy
  USDCNY: USDCNY Curncy
  EURUSD: EURUSD Curncy
  GBPUSD: GBPUSD Curncy
  USDHKD: USDHKD Curncy
  USDINR: USDINR Curncy
  USDJPY: USDJPY Curncy
  USDKRW: USDKRW Curncy
  USDSAR: USDSAR Curncy
  USDTWD: USDTWD Curncy
  USDZAR: USDZAR Curncy

Date range: 2015-01-01 to 2025-11-15

3. Fetch Forward Curve¶

Use ForwardCurve.fetch() to retrieve and build the forward curve with validation and FWD_SCALE handling.

# Fetch forward curve for a single currency using OOP interface
currency_pair = 'GBPUSD'

curve = ForwardCurve.fetch(
    config=config,
    currency=currency_pair,
    start_date=START_DATE,
    end_date=END_DATE,
    periodicity='D',  # 
    validate=True,
    verbose=True
    
)

print(f"\n{curve}")
print(f"Available tenors: {curve.tenors}")
curve.data.tail()
================================================================================
FORWARD CURVE RETRIEVAL: GBPUSD
================================================================================

[VALIDATING] GBPUSD forward curve tickers...
  [CACHE HIT] bdp: 6 tickers, fields=['QUOTATION_BASE_CURRENCY']
  PASS: All 6 tickers have base currency: GBP
  [CACHE HIT] bdp: 5 tickers, fields=['SECURITY_TYP']
  PASS: All 5 forward tickers have type: FORWARD

[FWD_SCALE] Retrieving scales for GBPUSD...
  [CACHE HIT] bdp: 5 tickers, fields=['FWD_SCALE']
  1M (GBP1M Curncy): FWD_SCALE = 4.0
  2M (GBP2M Curncy): FWD_SCALE = 4.0
  3M (GBP3M Curncy): FWD_SCALE = 4.0
  6M (GBP6M Curncy): FWD_SCALE = 4.0
  12M (GBP12M Curncy): FWD_SCALE = 4.0

[FETCHING] GBPUSD forward curve data...
  Period: 2015-01-01 to 2025-11-15
  Periodicity: D
  [CACHE HIT] bdh: GBPUSD Curncy PX_LAST D
  Spot (GBPUSD Curncy): 2837 observations
  [CACHE HIT] bdh: GBP1M Curncy PX_LAST D
  1M (GBP1M Curncy): 2837 observations
  [CACHE HIT] bdh: GBP2M Curncy PX_LAST D
  2M (GBP2M Curncy): 2837 observations
  [CACHE HIT] bdh: GBP3M Curncy PX_LAST D
  3M (GBP3M Curncy): 2837 observations
  [CACHE HIT] bdh: GBP6M Curncy PX_LAST D
  6M (GBP6M Curncy): 2837 observations
  [CACHE HIT] bdh: GBP12M Curncy PX_LAST D
  12M (GBP12M Curncy): 2837 observations

[CALCULATING] Building forward curve...

  Forward curve built: 2837 observations
  Columns: ['Date', 'Spot', 'FWD_1M_Points', 'FWD_1M_Rate', 'FWD_2M_Points', 'FWD_2M_Rate', 'FWD_3M_Points', 'FWD_3M_Rate', 'FWD_6M_Points', 'FWD_6M_Rate', 'FWD_12M_Points', 'FWD_12M_Rate']

ForwardCurve(GBPUSD, 2015-01-01 to 2025-11-15, n=2837, periodicity=D)
Available tenors: ['1M', '2M', '3M', '6M', '12M']
Date Spot FWD_1M_Points FWD_1M_Rate FWD_2M_Points FWD_2M_Rate FWD_3M_Points FWD_3M_Rate FWD_6M_Points FWD_6M_Rate FWD_12M_Points FWD_12M_Rate
2832 2025-11-10 1.317500 -0.400000 1.317460 -0.940000 1.317406 -1.410000 1.317359 -2.760000 1.317224 -12.730000 1.316227
2833 2025-11-11 1.315000 -0.510000 1.314949 -0.630000 1.314937 -0.760000 1.314924 -0.800000 1.314920 -7.300000 1.314270
2834 2025-11-12 1.313300 -0.330000 1.313267 -0.250000 1.313275 -0.170000 1.313283 0.460000 1.313346 -5.050000 1.312795
2835 2025-11-13 1.319200 -0.380000 1.319162 0.160000 1.319216 0.380000 1.319238 1.790000 1.319379 -3.800000 1.318820
2836 2025-11-14 1.317100 -0.470000 1.317053 0.060000 1.317106 0.210000 1.317121 0.600000 1.317160 -9.100000 1.316190

4. Analyze Term Structure¶

# Plot term structure using class method
fig = curve.plot_term_structure()
fig.show()

5. Historical Carry Analysis¶

# Calculate carry metrics using class method
carry_metrics = curve.carry_metrics()
carry_metrics.tail()
Date Spot 1M_Carry_Ann_% 1M_Premium_bps 1M_Quantile 2M_Carry_Ann_% 2M_Premium_bps 2M_Quantile 3M_Carry_Ann_% 3M_Premium_bps 3M_Quantile 6M_Carry_Ann_% 6M_Premium_bps 6M_Quantile 12M_Carry_Ann_% 12M_Premium_bps 12M_Quantile
2832 2025-11-10 1.317500 -0.036939 -3.693865 15.932323 -0.043403 -4.340291 15.368347 -0.042926 -4.292595 15.121607 -0.042013 -4.201264 18.117730 -0.096622 -9.662239 19.844907
2833 2025-11-11 1.315000 -0.047186 -4.718631 15.403595 -0.029144 -2.914449 15.897074 -0.023181 -2.318138 15.967571 -0.012201 -1.220073 19.351428 -0.055513 -5.551331 22.030314
2834 2025-11-12 1.313300 -0.030572 -3.057184 16.496299 -0.011580 -1.158024 16.602044 -0.005192 -0.519202 16.743038 0.007024 0.702450 20.267889 -0.038453 -3.845275 23.052520
2835 2025-11-13 1.319200 -0.035046 -3.504649 16.214311 0.007378 0.737821 17.166020 0.011554 1.155379 17.589002 0.027212 2.721221 21.713077 -0.028805 -2.880534 23.440254
2836 2025-11-14 1.317100 -0.043416 -4.341609 15.579838 0.002771 0.277124 16.989778 0.006395 0.639517 17.412760 0.009136 0.913596 20.479380 -0.069091 -6.909119 21.149101
# Plot historical forward premium using class method
fig = curve.plot_carry_history()
fig.show()
fig = curve.plot_spot_and_forwards()
fig.show()

6. Multi-Currency Comparison¶

# Analyze multiple currencies using ForwardCurve class
currencies_to_analyze = config.keys() # ['EURUSD', 'GBPUSD', 'USDJPY', 'AUDUSD', 'USDCAD', 'USDCHF', 'USDTWD']

curves = {}
for ccy in currencies_to_analyze:
    try:
        curves[ccy] = ForwardCurve.fetch(
            config=config,
            currency=ccy,
            start_date=START_DATE,
            end_date=END_DATE,
            periodicity='D',
            # verbose=True
        )
    except Exception as e:
        print(f"Error fetching {ccy}: {e}")

print(f"\n{'='*60}")
print(f"SUMMARY: Successfully fetched {len(curves)}/{len(currencies_to_analyze)} currencies")
print(f"{'='*60}")
for ccy, c in curves.items():
    print(f"  {c}")
============================================================
SUMMARY: Successfully fetched 16/16 currencies
============================================================
  ForwardCurve(USDAED, 2015-01-01 to 2025-11-15, n=2837, periodicity=D)
  ForwardCurve(AUDUSD, 2015-01-01 to 2025-11-15, n=2837, periodicity=D)
  ForwardCurve(USDBRL, 2015-01-01 to 2025-11-15, n=2832, periodicity=D)
  ForwardCurve(USDCAD, 2015-01-01 to 2025-11-15, n=2837, periodicity=D)
  ForwardCurve(USDCHF, 2015-01-01 to 2025-11-15, n=2837, periodicity=D)
  ForwardCurve(USDCNH, 2015-01-01 to 2025-11-15, n=2836, periodicity=D)
  ForwardCurve(USDCNY, 2015-01-01 to 2025-11-15, n=2837, periodicity=D)
  ForwardCurve(EURUSD, 2015-01-01 to 2025-11-15, n=2837, periodicity=D)
  ForwardCurve(GBPUSD, 2015-01-01 to 2025-11-15, n=2837, periodicity=D)
  ForwardCurve(USDHKD, 2015-01-01 to 2025-11-15, n=2837, periodicity=D)
  ForwardCurve(USDINR, 2015-01-01 to 2025-11-15, n=2834, periodicity=D)
  ForwardCurve(USDJPY, 2015-01-01 to 2025-11-15, n=2837, periodicity=D)
  ForwardCurve(USDKRW, 2015-01-01 to 2025-11-15, n=2832, periodicity=D)
  ForwardCurve(USDSAR, 2015-01-01 to 2025-11-15, n=2837, periodicity=D)
  ForwardCurve(USDTWD, 2015-01-01 to 2025-11-15, n=2837, periodicity=D)
  ForwardCurve(USDZAR, 2015-01-01 to 2025-11-15, n=2837, periodicity=D)
# Plot term structure for each currency pair (Interactive Plotly version)
import plotly.graph_objects as go
from plotly.subplots import make_subplots

# Create subplot grid with improved spacing
n_currencies = len(curves)
n_cols = 2
n_rows = (n_currencies + n_cols - 1) // n_cols  # Ceiling division

fig = make_subplots(
    rows=n_rows, cols=n_cols,
    subplot_titles=[f'<b>{ccy}</b>' for ccy in curves.keys()],
    vertical_spacing=0.08,     # Reduced for tighter layout
    horizontal_spacing=0.12    # Increased for better separation
)

for idx, (ccy, c) in enumerate(curves.items()):
    row = idx // n_cols + 1
    col = idx % n_cols + 1
    
    ts = c.term_structure()
    ts_fwd = ts[ts['Tenor'] != 'Spot'].copy()
    
    # Calculate individual y-axis range for this subplot
    y_max = ts_fwd['Premium_Ann_bps'].max()
    y_min = ts_fwd['Premium_Ann_bps'].min()
    y_range = y_max - y_min
    # Add 25% padding to ensure labels are visible
    y_padding = y_range * 0.25 if y_range > 0 else 50  # Minimum padding of 50 bps
    y_axis_max = y_max + y_padding
    y_axis_min = y_min - y_padding
    
    # Color based on premium sign with better colors
    colors = ['#27ae60' if x >= 0 else '#e74c3c' for x in ts_fwd['Premium_Ann_bps']]
    
    # Add bar trace with improved styling
    fig.add_trace(
        go.Bar(
            x=ts_fwd['Tenor'],
            y=ts_fwd['Premium_Ann_bps'],
            marker=dict(
                color=colors,
                line=dict(color='rgba(0,0,0,0.3)', width=1.5),
                opacity=0.85
            ),
            text=[f'{val:.0f}' for val in ts_fwd['Premium_Ann_bps']],
            textposition='outside',
            textfont=dict(size=11, color='black', family='Arial'),
            hovertemplate='<b>%{x}</b><br>' +
                          'Premium: %{y:.1f} bps<br>' +
                          f'Spot: {c.spot.iloc[-1]:.4f}<br>' +
                          '<extra></extra>',
            showlegend=False,
            width=0.6  # Bar width
        ),
        row=row, col=col
    )
    
    # Add zero line with better styling
    fig.add_hline(
        y=0, 
        line=dict(color='rgba(0,0,0,0.4)', width=1.5, dash='solid'),
        row=row, col=col
    )
    
    # Update axes for this subplot with individual range and better styling
    fig.update_xaxes(
        title_text='',
        row=row, col=col,
        tickfont=dict(size=11, family='Arial'),
        showline=True,
        linewidth=1,
        linecolor='rgba(0,0,0,0.2)'
    )
    fig.update_yaxes(
        title_text='Premium (bps p.a.)', 
        title_font=dict(size=10, family='Arial'),
        row=row, col=col,
        range=[y_axis_min, y_axis_max],
        tickfont=dict(size=10, family='Arial'),
        showline=True,
        linewidth=1,
        linecolor='rgba(0,0,0,0.2)'
    )

# Get the latest date from the first curve
latest_date = list(curves.values())[0].dates.iloc[-1]
date_str = latest_date.strftime('%Y-%m-%d')

# Update overall layout with improved aesthetics
fig.update_layout(
    title=dict(
        text=f'<b>Forward Premium Term Structure Comparison</b><br><sub>As of {date_str}</sub>',
        font=dict(size=20, color='#2c3e50', family='Arial'),
        x=0.5,
        xanchor='center',
        y=0.98,
        yanchor='top'
    ),
    height=320 * n_rows,  # Increased from 250 for better visibility
    width=1400,           # Set explicit width for better proportions
    showlegend=False,
    hovermode='closest',
    plot_bgcolor='white',
    paper_bgcolor='#fafafa',
    margin=dict(l=80, r=80, t=120, b=60),
    font=dict(family='Arial', size=11)
)

# Update subplot titles styling
for annotation in fig['layout']['annotations']:
    annotation['font'] = dict(size=13, family='Arial', color='#2c3e50')

# Update all y-axes to show gridlines with better styling
fig.update_yaxes(
    showgrid=True, 
    gridcolor='rgba(200,200,200,0.3)', 
    gridwidth=0.5,
    zeroline=False
)
fig.update_xaxes(
    showgrid=False
)

fig.show()
# Plot historical carry for each currency pair (Interactive Plotly version)
import plotly.graph_objects as go
from plotly.subplots import make_subplots

# Create subplot grid
n_currencies = len(curves)
n_cols = 2
n_rows = (n_currencies + n_cols - 1) // n_cols

fig = make_subplots(
    rows=n_rows, cols=n_cols,
    subplot_titles=[f'{ccy} Historical Forward Premium' for ccy in curves.keys()],
    vertical_spacing=0.10,
    horizontal_spacing=0.08
)

# Color palette for tenors
tenor_colors = {'1M': '#3498db', '2M': '#9b59b6', '3M': '#2ecc71', '6M': '#f39c12', '12M': '#e74c3c'}
tenors_to_plot = ['1M', '2M', '3M', '6M', '12M']  # Focus on key tenors

for idx, (ccy, c) in enumerate(curves.items()):
    row = idx // n_cols + 1
    col = idx % n_cols + 1
    
    # Get carry metrics
    carry = c.carry_metrics()
    
    # Plot each tenor
    for tenor in tenors_to_plot:
        col_name = f'{tenor}_Carry_Ann_%'
        if col_name in carry.columns:
            fig.add_trace(
                go.Scatter(
                    x=carry['Date'],
                    y=carry[col_name],
                    mode='lines',
                    name=tenor,
                    line=dict(color=tenor_colors[tenor], width=2),
                    hovertemplate='<b>%{x|%Y-%m-%d}</b><br>' +
                                  f'{tenor}: %{{y:.2f}}%<br>' +
                                  '<extra></extra>',
                    legendgroup=tenor,
                    showlegend=(idx == 0)  # Only show legend for first subplot
                ),
                row=row, col=col
            )
    
    # Add zero line
    fig.add_hline(y=0, line=dict(color='black', width=1, dash='dash'),
                  row=row, col=col, opacity=0.5)
    
    # Update axes
    fig.update_xaxes(title_text='Date', row=row, col=col, showgrid=True)
    fig.update_yaxes(title_text='Annualized Forward Premium (%)', row=row, col=col, showgrid=True)

# Update overall layout
fig.update_layout(
    title=dict(
        text='Historical Carry/Forward Premium Analysis (10Y)',
        font=dict(size=18, color='black'),
        x=0.5,
        xanchor='center'
    ),
    height=400 * n_rows,
    hovermode='x unified',
    plot_bgcolor='white',
    paper_bgcolor='white',
    legend=dict(
        orientation='h',
        yanchor='bottom',
        y=1.02,
        xanchor='center',
        x=0.5,
        font=dict(size=12)
    )
)

# Update all axes for better appearance
fig.update_xaxes(showgrid=True, gridcolor='lightgray', gridwidth=0.5)
fig.update_yaxes(showgrid=True, gridcolor='lightgray', gridwidth=0.5)

fig.show()

6.3 Cross-Currency Comparison Summary¶

Latest forward premium comparison across all currency pairs.

# Create summary comparison table
summary_data = []
tenors = ['1M', '2M', '3M', '6M', '12M']  # Tenors to include in summary

for ccy, c in curves.items():
    ts = c.term_structure()
    row = {'Currency': ccy, 'Spot': c.data['Spot'].iloc[-1]}
    for tenor in tenors:
        tenor_data = ts[ts['Tenor'] == tenor]['Premium_Ann_bps']
        row[f'{tenor} (bps)'] = tenor_data.values[0] if len(tenor_data) > 0 else None
    summary_data.append(row)

summary_df = pd.DataFrame(summary_data).set_index('Currency')

# Display with pandas styling
latest_date = curves[list(curves.keys())[0]].dates.iloc[-1]
print(f"Forward Premium p.a. Summary (as of {latest_date.strftime('%Y-%m-%d')})\n")

premium_cols = [f'{t} (bps)' for t in tenors]
summary_df.style.format(
    {**{'Spot': '{:.4f}'}, **{col: '{:.0f}' for col in premium_cols}}
).background_gradient(
    subset=premium_cols,
    cmap='RdYlGn',
    vmin=-400,
    vmax=400
)
Forward Premium p.a. Summary (as of 2025-11-14)

  Spot 1M (bps) 2M (bps) 3M (bps) 6M (bps) 12M (bps)
Currency            
USDAED 3.6730 -9 -14 -16 -16 -11
AUDUSD 0.6538 37 35 29 18 -3
USDBRL 5.2974 910 865 831 829 832
USDCAD 1.4023 -185 -200 -184 -171 -146
USDCHF 0.7940 -418 -448 -422 -400 -376
USDCNH 7.0991 -239 -245 -244 -215 -195
USDCNY 7.0993 -170 -206 -211 -185 -169
EURUSD 1.1621 203 219 204 190 172
GBPUSD 1.3171 -4 0 1 1 -7
USDHKD 7.7732 -114 -73 -71 -63 -57
USDINR 88.7425 190 211 205 217 227
USDJPY 154.5500 -366 -391 -364 -337 -306
USDKRW 1451.3500 -180 -199 -188 -166 -134
USDSAR 3.7500 113 104 97 94 80
USDTWD 31.1500 -820 -703 -545 -431 -353
USDZAR 17.0904 258 270 263 266 278
%%capture
# Convert notebook to HTML with Plotly support
# Note: Run all cells first to ensure plots are in outputs
!jupyter nbconvert  \
"01_forward_curve_analysis.ipynb" \
--to html \
--template lab \
--output "01_forward_curve_analysis" \
--output-dir output \
--no-prompt